title: Example description: Three small but common controls: focus / exposure point of interest, session interruption notifications, and movie stabilization mode.


import {
  Button, CaptureVideoPreviewView, HStack, Menu, Navigation, NavigationStack,
  Picker, Script, Spacer, Text, Toolbar, ToolbarItem,
  useEffect, useMemo, useObservable, VStack
} from "scripting"

const STAB_MODES = ["off", "standard", "cinematic", "cinematicExtended", "auto"] as const
type StabMode = typeof STAB_MODES[number]

const POI_PRESETS: { label: string; point: { x: number; y: number } }[] = [
  { label: "Center", point: { x: 0.5, y: 0.5 } },
  { label: "Top-L", point: { x: 0.2, y: 0.2 } },
  { label: "Top-R", point: { x: 0.8, y: 0.2 } },
  { label: "Bot-L", point: { x: 0.2, y: 0.8 } },
  { label: "Bot-R", point: { x: 0.8, y: 0.8 } },
]

function View() {
  const dismiss = Navigation.useDismiss()

  const isRunning = useObservable(false)
  const interruption = useObservable("—")
  const lastPOI = useObservable("—")
  const activeStabilization = useObservable("off")
  const recording = useObservable(false)
  const recordedFile = useObservable("")
  const stabMode = useObservable<StabMode>("auto")

  const { session, camera, movieOutput } = useMemo(() => {
    const camera = AVCaptureDevice.default("video")!
    const session = new AVCaptureSession()
    const input = new AVCaptureDeviceInput(camera)
    const movieOutput = new AVCaptureMovieFileOutput()

    session.configure(() => {
      session.sessionPreset = "high"
      if (session.canAddInput(input)) session.addInput(input)
      if (session.canAddOutput(movieOutput)) session.addOutput(movieOutput)
    })

    return { session, camera, movieOutput }
  }, [])

  // 中断监听
  useEffect(() => {
    session.addInterruptionListener((event, reason) => {
      if (event === "began") {
        interruption.setValue(`began · ${reason || "unknown"}`)
      } else {
        interruption.setValue("ended")
      }
    })
  }, [])

  // 启动 session + 初始稳定化
  useEffect(() => {
    async function start() {
      try {
        await session.startRunning()
        isRunning.setValue(true)
        // session.addOutput 之后 movieOutput 才有 video connection
        movieOutput.setVideoStabilizationMode(stabMode.value)
        activeStabilization.setValue(movieOutput.videoStabilizationMode)
      } catch (e) {
        await Dialog.alert({ message: `Failed to start: ${String(e)}` })
        dismiss()
      }
    }
    start()
    return () => {
      session.stopRunning().finally(() => session.dispose())
    }
  }, [])

  function applyPOI(p: { x: number; y: number }) {
    let did = false
    if (camera.isFocusPointOfInterestSupported) {
      camera.setFocusPointOfInterest(p)
      camera.setFocusMode("autoFocus")
      did = true
    }
    if (camera.isExposurePointOfInterestSupported) {
      camera.setExposurePointOfInterest(p)
      camera.setExposureMode("continuousAutoExposure")
      did = true
    }
    lastPOI.setValue(
      did
        ? `(${p.x.toFixed(2)}, ${p.y.toFixed(2)})`
        : "device does not support POI"
    )
  }

  function chooseStabilization(mode: StabMode) {
    stabMode.setValue(mode)
    const ok = movieOutput.setVideoStabilizationMode(mode)
    if (!ok) {
      lastPOI.setValue("setVideoStabilizationMode → false (no video connection?)")
      return
    }
    // active 在 startRunning 后由系统决定; 这里多读几次让 UI 反映现实
    activeStabilization.setValue(movieOutput.videoStabilizationMode)
  }

  async function toggleRecording() {
    if (recording.value) {
      await movieOutput.stopRecording()
      return
    }
    try {
      const path = `${FileManager.documentsDirectory}/session_control_clip.mov`
      try { FileManager.removeSync(path) } catch { }
      recording.setValue(true)
      const finalPath = await movieOutput.startRecording(path)
      recordedFile.setValue(finalPath)
      // 录制结束后 active 可能与录制中不同, 再读一次
      activeStabilization.setValue(movieOutput.videoStabilizationMode)
    } catch (e) {
      await Dialog.alert({ message: `Recording failed: ${String(e)}` })
    } finally {
      recording.setValue(false)
    }
  }

  return (
    <NavigationStack>
      <VStack
        navigationTitle="PR A · session control"
        toolbar={
          <Toolbar>
            <ToolbarItem placement="topBarTrailing">
              <Button title="Done" systemImage="xmark" action={dismiss} />
            </ToolbarItem>
          </Toolbar>
        }
      >
        <CaptureVideoPreviewView
          session={session}
          videoDevice={camera}
          videoGravity="resizeAspectFill"
          frame={{ height: 360 }}
          cornerRadius={12}
          masksToBounds
        />

        <VStack alignment="leading" spacing={4} padding={8}>
          <Text font="caption">Status</Text>
          <Text font="footnote">
            running: {String(isRunning.value)} · stabilization (active): {activeStabilization.value}
          </Text>
          <Text font="footnote">interruption: {interruption.value}</Text>
          <Text font="footnote">last POI: {lastPOI.value}</Text>
          {recordedFile.value ? (
            <Text font="footnote" foregroundStyle="secondaryLabel">
              saved → {recordedFile.value}
            </Text>
          ) : null}
        </VStack>

        <VStack alignment="leading" spacing={4} padding={8}>
          <Text font="caption">Tap-to-focus presets</Text>
          <HStack spacing={6}>
            {POI_PRESETS.map(p => (
              <Button
                key={p.label}
                title={p.label}
                action={() => applyPOI(p.point)}
              />
            ))}
          </HStack>
        </VStack>

        <HStack padding={8}>
          <Text font="caption">Stabilization</Text>
          <Spacer />
          <Menu title={stabMode.value}>
            <Picker
              title="Stabilization"
              value={stabMode.value}
              onChanged={(v: string) => chooseStabilization(v as StabMode)}
            >
              {STAB_MODES.map(m => (
                <Text key={m} tag={m}>{m}</Text>
              ))}
            </Picker>
          </Menu>
        </HStack>

        <HStack padding={8}>
          <Button
            title={recording.value ? "Stop recording" : "Start recording"}
            action={toggleRecording}
          />
          <Spacer />
          <Text font="footnote" foregroundStyle="secondaryLabel">
            Tip: ⌘+H (simulator: Hardware → Home) triggers a background interruption.
          </Text>
        </HStack>
      </VStack>
    </NavigationStack>
  )
}

async function run() {
  await Navigation.present({ element: <View /> })
  Script.exit()
}

run()